package com.liyunc.demo.bundle.security.filter;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.common.base.Strings;
import com.liyunc.demo.bundle.security.config.properties.JwtConfigurationProperties;
import com.liyunc.demo.comp.auth.AuthenticatedAccount;
import com.liyunc.demo.comp.auth.AuthenticatedUser;
import com.liyunc.demo.comp.common.exceptions.ApplicationException;
import com.liyunc.demo.comp.common.exceptions.enums.ErrorCode;
import com.liyunc.demo.comp.common.rest.response.GeneralResponse;
import com.liyunc.demo.comp.jwt.JwtCodec;
import com.liyunc.demo.comp.jwt.JwtPayload;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.BeansException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import org.springframework.http.MediaType;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.ProviderNotFoundException;
import org.springframework.security.config.BeanIds;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.GenericFilterBean;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.PrintWriter;
import java.util.Objects;

/**
 * @author liyuncong
 * @version 1.0
 * @file AbstractAuthFilter.java
 * @brief AbstractAuthFilter
 * @details AbstractAuthFilter
 * @date 2022-03-30
 *
 * Edit History
 * ----------------------------------------------------------------------------
 * DATE                        NAME               DESCRIPTION
 * 2022-03-30                  liyuncong          Created
 */
@Slf4j
public abstract class AbstractAuthFilter extends GenericFilterBean
    implements ApplicationContextAware {

    public record LoginAttempt(
        Boolean invokeOtherFilter,
        Boolean isLoginAttempt,
        Authentication authentication
    ) {
        public static LoginAttempt of(
            Boolean invokeOtherFilter,
            Boolean isLoginAttempt,
            Authentication authentication
        ) {
            return new LoginAttempt(
                invokeOtherFilter,
                isLoginAttempt,
                authentication);
        }

        public Boolean getInvokeOtherFilter() {
            return invokeOtherFilter;
        }

        public Boolean getLoginAttempt() {
            return isLoginAttempt;
        }

        public Authentication getAuthentication() {
            return authentication;
        }
    }

    @Autowired
    protected JwtCodec<Object> codec;
    @Autowired
    protected JwtConfigurationProperties config;
    protected ApplicationContext applicationContext;
    private final boolean sendResultAfterAuthSucceed;

    public AbstractAuthFilter(boolean sendResultAfterAuthSucceed) {
        this.sendResultAfterAuthSucceed = sendResultAfterAuthSucceed;
    }

    protected AuthenticationManager authenticationManager() {
        return applicationContext.getBean(BeanIds.AUTHENTICATION_MANAGER,
            AuthenticationManager.class);
    }

    protected ObjectMapper objectMapper() {
        return applicationContext.getBean(ObjectMapper.class);
    }

    @Override
    public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
        this.applicationContext = applicationContext;
    }

    @Override
    public void doFilter(
        ServletRequest servletRequest,
        ServletResponse servletResponse,
        FilterChain filterChain
    ) throws IOException, ServletException {
        Boolean invokeOtherFilter = Boolean.TRUE;
        try {
            final String filterName = this.getAuthFilterName();
            final Object attribute = servletRequest.getAttribute(filterName);
            if (Objects.nonNull(attribute)) {
                invokeOtherFilter = Boolean.TRUE;
                return;
            }
            servletRequest.setAttribute(filterName, Boolean.TRUE);
            if (servletRequest instanceof HttpServletRequest request
                && servletResponse instanceof HttpServletResponse response) {
                String logoutUri = "/api/v1/token/logout";
                String logoutParam = "logout";
                String uri = request.getRequestURI();
                Authentication oldAuth = SecurityContextHolder.getContext().getAuthentication();
                // do sing out
                if (Objects.equals(uri, logoutUri) && !Strings.isNullOrEmpty(logoutParam)) {
                    log.info("Try to logout, current auth is {}", oldAuth);
                    this.doLogout(request, response);
                    invokeOtherFilter = Boolean.FALSE;
                }
                // do sing in
                if (matchesLoginAttempt(request)) {
                    log.info("Try read login credential");
                    LoginAttempt loginAttempt = matchesAuthCredential(request, response);
                    invokeOtherFilter = loginAttempt.getInvokeOtherFilter();
                    Authentication authentication = loginAttempt.getAuthentication();
                    log.info("Login filter is {}, auth is {}",
                        invokeOtherFilter, authentication);
                    if (Objects.isNull(authentication)) {
                        log.info("Got empty authentication, give up.");
                        throw new ApplicationException(ErrorCode.INVALID_ARGUMENT);
                    }
                    Authentication result = authenticationManager().authenticate(authentication);
                    if (Objects.nonNull(result) && result.isAuthenticated()) {
                        SecurityContextHolder.getContext().setAuthentication(result);
                        log.debug("Auth succeed with {}, result is {}", authentication, result);
                        // return jwt
                        sendAuthToken(result, request, response);
                        if (loginAttempt.getLoginAttempt()) {
                            invokeOtherFilter = false;
                        }
                    } else {
                        throw new ApplicationException(ErrorCode.USERNAME_OR_PASSWORD_ERROR);
                    }
                }
            }
        } catch (ProviderNotFoundException exception) {
            log.error("Could not authenticate", exception);
            if (servletResponse instanceof HttpServletResponse response) {
                invokeOtherFilter = false;
                sendLoginErrors(
                    new ApplicationException(ErrorCode.USERNAME_OR_PASSWORD_ERROR),
                    response
                );
            }
        } finally {
            if (invokeOtherFilter && !servletResponse.isCommitted()) {
                filterChain.doFilter(servletRequest, servletResponse);
            }
        }
    }

    protected abstract String getAuthFilterName();

    protected abstract Boolean matchesLoginAttempt(HttpServletRequest request);

    protected abstract LoginAttempt matchesAuthCredential(
        HttpServletRequest request,
        HttpServletResponse response
    ) throws IOException;

    protected void doLogout(HttpServletRequest request, HttpServletResponse response) {
        Cookie cookie = this.tokenCookie(null);
        cookie.setMaxAge(0);
        response.addCookie(cookie);
    }

    protected Cookie tokenCookie(final String token) {
        Cookie cookie = new Cookie("authToken", token);
        cookie.setHttpOnly(true);
        cookie.setPath("/");
        // TODO 设置过期时间，ttl时间
        int expire = 1222;
        int ttl = 1111;
        cookie.setMaxAge(Math.max(expire, ttl));
        return cookie;
    }

    protected void sendAuthToken(
        final Authentication authentication,
        final HttpServletRequest request,
        final HttpServletResponse response
    ) throws IOException {
        if (response.isCommitted()) {
            log.warn("Response was already committed.");
            return;
        }
        if (!this.sendResultAfterAuthSucceed) {
            return;
        }
        if (authentication instanceof AuthenticatedAccount account) {
            AuthenticatedUser<?> authenticatedUser = account.getUser();
            JwtPayload<Object> payload = new JwtPayload<>(
                authenticatedUser.getUserId(),
                authenticatedUser.getLoginUsername(),
                authenticatedUser.getProperties()
            );
            String token = codec.encode(payload, config);
            Cookie cookie = tokenCookie(token);
            response.addCookie(cookie);
            response.addHeader("Content-Type", MediaType.APPLICATION_JSON_VALUE);
            response.setContentType("application/json;charset=utf-8");
            response.setStatus(HttpServletResponse.SC_OK);
            String json = objectMapper().writeValueAsString(GeneralResponse.of(token));
            try (PrintWriter out = response.getWriter()) {
                out.write(json);
            }
            return;
        }
        log.warn("Could not send auth token to authenticated user");
    }

    protected void sendLoginErrors(
        final ApplicationException exception,
        final HttpServletResponse response
    ) throws IOException {
        if (response.isCommitted()) {
            log.warn("Response was already committed.");
            return;
        }
        response.setHeader("Content-Type", MediaType.APPLICATION_JSON_VALUE);
        String json = objectMapper().writeValueAsString(
            GeneralResponse.of(exception.getCode(), exception.getMessage(), null));
        try (PrintWriter writer = response.getWriter()) {
            writer.write(json);
        }
    }
}
